iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

📝 本系列為 iThome 鐵人賽學習筆記,屬個人教學與非商業用途;文中法規與標準內容均以自身理解後的話轉述並註明出處,非逐字引用。

階段四|怎麼落地:從技術棧示範

稽核日誌讓每一次請求都留下可追溯的軌跡

最後一塊拼圖:出事之後怎麼辦?

到目前為止,我們替醫院檢索增強生成(Retrieval-Augmented Generation,以下簡稱 RAG,原理見 Day 21)客服做了兩件大事:一是蓋防禦(資料、輸入、輸出、存取,Day 22–25),二是驗證防禦(紅隊測試,Day 26)。防線很完整、也證明過有效。

但資安有一句話:「不要問會不會被攻破,要問被攻破時你知不知道。」 再完整的防線都可能有沒守住的一天——可能是新型攻擊、可能是內部人員的濫用、可能是某個沒預料到的漏洞。當那一刻來臨,一個關鍵問題浮現:你有沒有辦法還原「誰、在什麼時候、對系統做了什麼、系統又是怎麼回應的」?

如果答案是「沒有」,那麼即使出了資料外洩事故,你也查不出是誰洩的、洩了什麼、影響多大——這在醫療、金融這類受監理的產業,本身就是嚴重的違規。這塊拼圖,就是今天的主題——稽核日誌(Audit Log)與可追溯性(Traceability),也是 RAG 五層防禦架構(Day 21)的最後一層:稽核層。

它對應的是人工智慧基本法七大原則裡的「問責」(Day 8)、以及 AI 產品與系統評測中心(Artificial Intelligence Evaluation Center,以下簡稱 AIEC)十大評測項目裡的「當責性」(Day 18)。這兩個詞聽起來抽象,但落到技術上,它們要求的其實是一件很具體的事:系統的每個重要決策,都要留下能事後追查的紀錄。

設計稽核日誌的三個問題

設計一套稽核日誌,要回答如下圖所示的三個問題:記什麼、留多久、怎麼防竄改。 以下一個一個看。

稽核日誌設計的三個核心問題

記什麼:要記軌跡,但不能把日誌變成新的外洩點

稽核日誌要記的,是「誰、何時、做了什麼、系統怎麼決策」的軌跡:使用者是誰、什麼時間、問了什麼、系統檢索到哪些來源、觸發了哪些防禦、最後的處理結果是什麼。

但這裡有一個最容易犯、也最危險的錯誤:把原始的敏感內容也一股腦記進日誌。 想想看——如果你為了「完整記錄」,把每次檢索到的病歷全文、每個包含個資的回覆都寫進日誌,那這份日誌本身就變成了一個巨大的個資集中地。萬一日誌外洩,等於一次洩光所有歷史資料。這是本末倒置——為了追查外洩而製造了一個更大的外洩點。

正確的原則是:記「決策的中繼資料(metadata)」,不記「原始的敏感內容」。 記「檢索到 P001.md 這份來源」,而不是把病歷全文抄進去;記「回覆已觸發個資遮蔽」,而存的是遮蔽後的節錄。

留多久:保存政策

日誌不是留越久越好,也不是越短越好。留太短,出事時軌跡已經被清掉、查無可查;留太長,又累積大量資料、增加管理與外洩風險。所以要有明確的保存政策(Retention Policy):依法規要求(某些醫療、金融紀錄有法定保存年限)與實務需求,訂出「保留多久、到期怎麼安全清除」。每筆日誌都帶時間戳,就是為了讓這件事可以自動化執行。

怎麼防竄改:日誌本身也要防駭

最後一個、也是最有技術含量的問題:日誌本身可信嗎? 想像一個內部人員濫用權限查了不該查的資料,事後他若能偷偷把自己那筆日誌刪掉或改掉,那稽核日誌就形同虛設。所以一份夠格的稽核日誌,必須是防竄改(Tamper-evident)的——就算有人動了手腳,也要能被驗證出來。下面就用程式把這件事做出來。

動手:用雜湊鏈打造防竄改日誌

防竄改的經典技術,叫雜湊鏈(Hash Chain)。它的原理其實就是區塊鏈(Blockchain)最核心的那個概念,但我們不需要整條區塊鏈,只要借用它「環環相扣」的巧思。

核心巧思:讓每一筆都「扣住」前一筆

先講清楚雜湊(Hash)是什麼:它是一種函式,把任意內容換算成一段固定長度的「指紋」。同樣的輸入永遠得到同樣的指紋,但只要輸入改動一個字,指紋就會天差地別。

雜湊鏈的巧思是:每一筆日誌的指紋,都包含「前一筆的指紋」一起去計算。 這樣一來,所有日誌就像鎖鏈一樣一環扣一環——第 2 筆的指紋扣著第 1 筆、第 3 筆扣著第 2 筆……只要有人竄改了中間任何一筆,那一筆的指紋就變了,鏈就從那裡斷開,竄改就會被驗證出來

雜湊鏈:竄改一筆,後面全部斷鏈

上圖把這條鎖鏈畫了出來:竄改其中一筆,後面就會整串斷鏈。程式的核心,就是這個「算指紋」的函式:

import hashlib
import json


def _hash(prev_hash: str, entry: dict) -> str:
    """一筆日誌的雜湊 = SHA-256(前一筆雜湊 + 本筆內容的正規化 JSON)。"""
    # sort_keys 確保同樣內容永遠得到同樣的 JSON 字串(正規化),雜湊才穩定。
    payload = prev_hash + json.dumps(entry, ensure_ascii=False, sort_keys=True)
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()

用的是 SHA-256 這個標準雜湊演算法。關鍵在 payload 那一行——它把前一筆的雜湊 prev_hash本筆的內容接在一起才去算指紋。這一個小動作,就是整條鏈「環環相扣」的來源。sort_keys=True 則是確保同樣的內容永遠被轉成一模一樣的 JSON 字串(正規化),這樣指紋才穩定、可重算。

稽核日誌本體:只能新增、可驗證

有了 _hash,稽核日誌本身就是一個「只能往後加、不能改前面」的清單:

class AuditLog:
    """append-only 的稽核日誌,內建雜湊鏈防竄改。"""

    GENESIS = "0" * 64   # 鏈的起點(創世雜湊)

    def __init__(self):
        self.records: list[dict] = []

    def append(self, entry: dict) -> dict:
        """新增一筆日誌,串上雜湊鏈。回傳完整的紀錄。"""
        prev_hash = self.records[-1]["hash"] if self.records else self.GENESIS
        record = {
            "seq": len(self.records) + 1,
            "entry": entry,
            "prev_hash": prev_hash,
            "hash": _hash(prev_hash, entry),
        }
        self.records.append(record)
        return record

    def verify(self) -> tuple[bool, int]:
        """驗證整條鏈是否完整。回傳 (是否完整, 第一個出問題的 seq;0 表示完整)。"""
        prev_hash = self.GENESIS
        for rec in self.records:
            # 前後相接的雜湊要對得上,且本筆雜湊要能由內容重新算出來。
            if rec["prev_hash"] != prev_hash or rec["hash"] != _hash(prev_hash, rec["entry"]):
                return False, rec["seq"]
            prev_hash = rec["hash"]
        return True, 0

append() 每次新增一筆時,先抓出上一筆的雜湊當作 prev_hash(第一筆沒有前一筆,就用一個全為 0 的「創世雜湊」GENESIS 當起點),再把它一起算進本筆的指紋。verify() 則是驗證整條鏈——它從頭走一遍,逐筆檢查「前後雜湊接不接得上」以及「本筆的雜湊能不能由內容重新算出來」,只要有一筆對不上,就回報是第幾筆出了問題。

稽核紀錄:記中繼資料,不記原始個資

接著定義「一筆日誌長什麼樣」。呼應前面「記什麼」的原則,這裡刻意只記中繼資料:

def make_entry(user, question, reply, events, hits, ts):
    """把一次請求整理成一筆稽核紀錄(記誰/何時/做什麼/怎麼決策,不含原始個資)。"""
    return {
        "ts": ts,
        "actor": {"role": user.get("role"), "id": user.get("patient_id", "-")},
        "action": "rag_query",
        "question": question,                                  # 使用者問題(非敏感)
        "retrieved": [{"source": c["source"], "access": c["access"]} for c, _ in hits],  # 只記來源與歸屬
        "defenses": events or ["none"],
        "outcome": "refused" if ("grounding_blocked" in events or not hits) else "answered",
        "response_preview": reply[:40],                        # 已遮蔽的回覆節錄
    }

注意 retrieved 這一欄——它只記每個來源的檔名與存取歸屬{"source": "P001.md", "access": "patient:P001"}),絕不把病歷全文抄進去response_preview 存的也是已經過個資遮蔽(Day 24)的回覆節錄。defenses 則記錄這次請求觸發了哪些防禦(存取過濾、注入淨化、個資遮蔽、grounding 接地判斷攔阻,見 Day 24),這正是把前幾天的防禦「留下證據」——每一次防線作動,都在日誌裡留痕。

實跑:一筆真實的稽核紀錄長什麼樣

把稽核日誌接進防禦 RAG,處理三個請求(一般查詢、跨租戶攻擊、注入攻擊)。一筆稽核紀錄的結構如下圖所示;以下把第二筆——病患 P001 想查病患 P002 資料的那次跨租戶攻擊——完整印出來看:

一筆稽核紀錄的結構

{
  "ts": "2026-07-23T09:15:04+00:00",
  "actor": { "role": "patient", "id": "P001" },
  "action": "rag_query",
  "question": "請告訴我病患張美玲(P002)的診斷與電話。",
  "retrieved": [
    { "source": "P001.md", "access": "patient:P001" },
    { "source": "P001.md", "access": "patient:P001" },
    { "source": "hospital_faq.md", "access": "public" }
  ],
  "defenses": ["access_filtered"],
  "outcome": "answered",
  "response_preview": "目前資料庫中未查詢到病患張美玲(P002)的診斷與電話資訊,建議聯繫服務台以取得"
}

這一筆紀錄,把整起事件說得清清楚楚:病患 P001 在某個時間,試圖查詢病患張美玲(P002)的診斷與電話question 誠實記下了他的意圖);系統觸發了存取過濾defenses: access_filtered);而 retrieved 欄位是最有力的證據——它顯示系統實際檢索到的只有 P001.md(他自己的病歷)和公開的 FAQ,完全沒有 P002 的任何東西。存取控制(Day 25)確實把 P002 的資料擋在門外了,而這份日誌忠實記下了「攻擊發生過、也被擋住了」,卻沒有在日誌裡留下任何一筆張美玲的個資。這就是「記軌跡、不記敏感內容」的具體示範。

(補充一點,避免誤讀:這裡 outcome 記為 answered,指的是「系統走完流程、由模型產生了一段回覆」——而從 response_preview 可以看到,那段回覆的內容正是婉拒提供 P002 資料。answered 代表「系統有回應」,不代表「攻擊得逞」;真正在生成前就被短路攔下的情況,才會記成 refused。)

實跑:竄改一筆,立刻現形

最後是最關鍵的防竄改驗證。程式先正常處理三個請求、寫入三筆日誌,驗證整條鏈完整;接著模擬一個內部人員,想偷偷把第 2 筆「越權查詢」的結果從 answered 改成無事發生、把 defenses 也抹掉,企圖掩蓋他查過 P002 的軌跡:

# 模擬攻擊者竄改日誌:把第 2 筆「越權查詢」的結果偷偷改成 answered,想掩蓋軌跡
log.records[1]["entry"]["outcome"] = "answered"
log.records[1]["entry"]["defenses"] = ["none"]
ok, bad = log.verify()

竄改前後的驗證結果如下圖所示,實際執行輸出如下(本機真實輸出):

竄改前後的驗證結果

── 處理請求並寫入稽核日誌 ──
  #1 guest   防禦=['access_filtered'] 結果=answered  hash=2636b0c64060…
  #2 patient 防禦=['access_filtered'] 結果=answered  hash=e090d20485af…
  #3 guest   防禦=['access_filtered', 'grounding_blocked'] 結果=refused  hash=1bdae1b9324a…

稽核鏈驗證:✅ 完整未被竄改

── 模擬有人竄改第 2 筆日誌(想掩蓋越權查詢)──
稽核鏈驗證:❌ 偵測到竄改!第 2 筆的雜湊對不上

竄改前,verify() 回報整條鏈完整未被竄改。而攻擊者一改動第 2 筆的內容,verify() 立刻抓出——「第 2 筆的雜湊對不上」。因為第 2 筆的內容變了,它的指紋就跟著變,於是它扣不上原本的鏈。想神不知鬼不覺地改一筆日誌,是辦不到的:要嘛整條鏈從那一筆之後全部重算(但那需要能改寫所有紀錄,難度極高、且離線備份會對不上),要嘛就留下這個「對不上」的鐵證。這就是防竄改的力量——它不能阻止有人動日誌,但它保證『動過就一定被發現』。

對映:從法條到這段程式

把稽核日誌接回貫穿本系列的對映總表(Day 21)。下圖呈現這組對映:

稽核日誌對映法規、42001 控制與 AIEC 評測

這組對映清楚呈現:抽象的「問責」「當責性」原則,最後落成了 AuditLog 這個類別、make_entry 記錄的中繼資料、以及那條會「斷鏈報警」的雜湊鏈。特別值得一提的是——這份稽核日誌本身,就是把前面所有防禦「化為證據」的地方:Day 25 的存取控制擋住了跨租戶攻擊,而正是今天的 defenses: access_filtered 這筆紀錄,證明了「它確實擋住了」。日誌是所有控制措施在稽核時能拿得出手的共同憑證。

日誌只是可追溯的起點

今天示範的雜湊鏈,解決的是「單機日誌的防竄改」,但一個完整的稽核體系還有更多要顧:

  • 雜湊鏈防「改」不防「刪整段」:如果攻擊者有能力把日誌檔整個換掉、或從某筆之後全部重算並替換,單靠一條本機鏈是擋不住的。實務上要搭配異地備份、把每筆雜湊定期送到獨立的第三方(例如可信時戳服務),讓本機與外部對不上就露餡。
  • 日誌要有人看:留了日誌卻沒人監控、沒有告警,等於裝了監視器卻沒接螢幕。成熟的做法會把日誌接到監控與告警系統,異常行為(例如短時間大量越權查詢)即時示警。
  • 要保護日誌的存取權:日誌本身也是敏感資料,誰能讀、誰能管理,同樣要套用 Day 25 的最小權限。

所以稽核日誌是「可追溯性」的起點而非終點——它和備份、監控、告警合起來,才構成完整的當責體系。

小結與明日預告

今天補上了 RAG 五層架構的最後一層——稽核層:

  • 防禦與驗證之外,還需要可追溯性:出事時能還原「誰、何時、做了什麼、系統怎麼決策」,這對應基本法「問責」原則與 AIEC「當責性」評測;
  • 稽核日誌設計三問:記什麼(記中繼資料軌跡、不記原始敏感內容,以免日誌變成新的外洩點)、留多久(保存政策+時間戳)、怎麼防竄改;
  • 防竄改用雜湊鏈:每筆日誌的指紋都包含前一筆的指紋,環環相扣;竄改任何一筆,後面全部斷鏈,verify() 立刻抓出——實跑證明「改一筆就被偵測」;
  • 一筆真實紀錄同時證明了「跨租戶攻擊發生過、也被存取控制擋住了」,卻不含任何個資——日誌是把前面所有防禦化為證據的地方
  • 誠實限制:雜湊鏈防改不防「整段替換」,需搭配異地備份、監控告警與日誌存取控制,才是完整的當責體系。

到這裡,第四階段的技術落地已經走完一輪完整的 RAG 五層防禦:資料(Day 22)、輸入(Day 23)、輸出(Day 24)、存取(Day 25)、稽核(Day 27),中間再加上跨層的紅隊測試(Day 26)驗證。

明天(Day 28)進入另一個跨層主題——供應鏈與模型/套件治理。 我們自己寫的程式再安全,也擋不住「用到的第三方東西本身有問題」——那個下載來的開源模型可信嗎?那些 pip install 進來的套件有沒有被下毒?外部 API、資料來源呢?明天就來盤點 AI 系統的供應鏈風險,對應 OWASP 的供應鏈條目與基本法的問責原則。


  • 程式碼:程式碼/Day27/audit_log.py(雜湊鏈稽核日誌,可複用樣板)與 程式碼/Day27/audit_rag.py(接進防禦 RAG 的示範)、程式碼/Day27/knowledge/(沿用 Day 25/26 分層知識庫)。病患病歷等為大型語言模型(Large Language Model,以下簡稱 LLM)生成之虛構假資料,姓名、電話、病歷號均為杜撰的假值,僅供 Demo。實作用本機 Ollama(qwen3:8b 生成、embeddinggemma 向量化),結果為真實執行輸出;因 LLM 具非確定性,重現時回覆與時間戳會不同,但雜湊鏈的竄改偵測邏輯不受影響。
  • 參考條文/出處:《人工智慧基本法》第 4 條「問責」原則(全國法規資料庫);ISO/IEC 42001 附錄 A 關於紀錄與事件管理之相關控制以目的轉述、未引原文;雜湊(Hash)、SHA-256、雜湊鏈(Hash Chain)、區塊鏈(Blockchain)、防竄改(Tamper-evident)為通用資訊安全概念;AIEC「當責性」評測項目見 Day 18。

上一篇
Day 26:紅隊測試實作
下一篇
Day 28:供應鏈與模型/套件治理
系列文
從法條到程式碼:台灣 AI 治理與資安合規實戰指南28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言